大家經過Nuxt 3 學習筆記這一系列文章,應該對於 Nuxt 3 有初步的理解,接下來我們將進入實戰部分,我將會以 Nuxt 3 來實作部落格網站,讓已經註冊的使用者可以在網站上發布文章,實作這個網站的程式碼可能不會講解得非常仔細,但一些實務開發上會需要注意的細節我會把個人經驗做一個紀錄,大家可以再參考看看,文末也都會附上完整的範例程式。接下來,讓我們開始吧!
這個系列實作的部落格,會把會員與文章等資料儲存於伺服器的資料庫之中,大家可以選擇自己習慣或合適的資料庫來做儲存。為了方便及後續的展示,我最終決定使用 Prisma 搭配本地的 SQLite 來當作儲存體,讓大家測試時不用再煩惱怎麼架設資料庫,可以快速的執行範例程式碼。
你可以在自己的 Nuxt 專案或從新專案開始進行,若你已經有自己的資料庫,也可以直接跳過此段介紹後續實作自己的後端 API 來接續我們的實作系列。
Prisma 操作起來很像 ORM (Object-Relational Mapping),但實際上依據官網的說明,其實不大依樣,Prisma 透過撰寫並根據 Schema
來建立或操作資料庫,在進行 CRUD 的操作是,都是透過 Prisma Client 進行,這也是最方便的地方,此外也支援多種資料庫的來源,只要操作 Model 就可以映射到資料庫的資料,不再需要寫 SQL,在一些情境之下是非常方便的。
首先,使用 NPM 安裝 prisma
與 @prisma/client
npm install -D prisma @prisma/client
打開終端機 (Terminal) 於 Nuxt 專案目錄中, 使用下列指令,初始化一個 Prisma 的 Schema
npx prisma init
初始化完成後,會建立一個 schema.prisma
檔案。
./prisma/schema.prisma
檔案內容如下,這裡就是定義我們資料庫位置與 Schema
的地方,之後我們就可以透過 Prisma 的 Client 使用 ORM 來操作資料庫。
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
我們調整 ./prisma/schema.prisma
檔案內容,將 datasource
替換為 SQLite 並儲存在本地的 ./dev.db
。
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
你也可以將 provider 替換為 PostgreSQL 或 MySQL 等,可以參考這裡,但要注意可能後面定義的 Schema 語法會略微不同。
接下來我們定義一個 User
的資料表,在 schema.prisma
撰寫如下:
model User {
id String @id @default(uuid())
providerName String?
providerUserId String?
nickname String @default("User")
email String @unique
password String?
avatar String?
emailVerified Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
這張 User
資料表,將用作於部落格的登入使用者做使用,也可以視為是會員系統使用的資料表,大家也可以依據需求來擴增欄位,以下稍微講述一下各個欄位將作為何用。
google
。如果為空值 (null)
表示使用者用電子信箱註冊登入。providerName
搭配使用,第三方供應商通常也會有一組專屬於使用者的 Id,以此我們就可以來比對登入的是哪位使用者。如果為空值 (null)
表示使用者用電子信箱註冊登入。User
。@unique
表示,電子信箱是系統中唯一。空值 (null)
。false
,表示使用者的電子信箱是尚未通過驗證。./prisma/schema.prisma
檔案內容看起來如下:
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model User {
id String @id @default(uuid())
providerName String?
providerUserId String?
nickname String @default("User")
email String @unique
password String?
avatar String?
emailVerified Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
當我們調整好 schema 後,就可以執行下列指令,來初始化資料庫,Prisma 會依照 schema.prism
來幫我們建立對應的資料表。
npx prisma db push
初始化完畢後,你可以登入你的資料庫查看是否建立成功,也可使用 Prisma 提供的 Prisma Studio 來快速的檢視與操作資料庫內的資料。Prisma Studio 已經內建在 prisma 套件中,執行以下指令後,就會啟動一個 Web 服務,如 http://localhost:5555,我們就可以在網頁中查看資料庫內的資料囉!
npx prisma studio
可以在 Prisma Studio 看到我們建立的 User
資料表,也將是我們稍後使用 Prisma 操作 ORM 所對應的 User Model。
最後記得執行下列指令來產生 Prisma Client,這樣我們就可以在 Nuxt 3 中使用 Prisma Client 操作資料庫囉!
npx prisma generate
我們接下來就能使用如下程式碼建立 Prisma Client,後續可以用來來操作 Model,更多的 Prisma Client API 可以參考官方文件。
import { PrismaClient } from '@prisma/client'
const prismaClient = new PrismaClient()
我們建立一隻 Server API,新增 ./server/api/test-create-user.get.js
,用來測試收到請求後建立一個測試使用者,詳細的程式碼如下:
import { PrismaClient } from '@prisma/client'
const prismaClient = new PrismaClient()
export default defineEventHandler(() => {
const user = prismaClient.user.create({
data: {
providerName: null,
providerUserId: null,
nickname: 'Ryan',
email: 'ryanchien8125@gmail.com',
password: '這裡要放密碼的雜湊值',
avatar: '',
emailVerified: true
}
})
return user
})
當我們送出 /api/test-create-user
後,後端會使用 Prisma Client 操作 User Model,我們就能使用 ORM 來建立出使用者的資料庫記錄。
我們將前面系列文章,所串接的 Google OAuth 及 Cookie 做一個結合,讓使用者透過 Google Auth 登入後可以自動的註冊建立使用者或登入產生 Access Token。
./server/api/auth/google.post.js
程式碼如下:
import { OAuth2Client } from 'google-auth-library'
import jwt from 'jsonwebtoken'
import db from '@/server/db'
const runtimeConfig = useRuntimeConfig()
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const oauth2Client = new OAuth2Client()
oauth2Client.setCredentials({ access_token: body.accessToken })
const userInfo = await oauth2Client
.request({
url: 'https://www.googleapis.com/oauth2/v3/userinfo'
})
.then((response) => response.data)
.catch(() => null)
oauth2Client.revokeCredentials()
if (!userInfo) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid token'
})
}
let userRecord = await db.user.getUserByEmail({
email: userInfo.email
})
if (userRecord) {
if (
(userRecord.providerName === 'google' && userRecord.providerUserId === userInfo.sub) === false
) {
throw createError({
statusCode: 400,
statusMessage: 'This email address does not apply to this login method'
})
}
} else {
userRecord = await db.user.createUser({
providerName: 'google',
providerUserId: userInfo.sub,
nickname: userInfo.name,
email: userInfo.email,
password: null,
avatar: userInfo.picture,
emailVerified: userInfo.email_verified
})
}
const jwtTokenPayload = {
id: userRecord.id
}
const maxAge = 60 * 60 * 24 * 7
const expires = Math.floor(Date.now() / 1000) + maxAge
const jwtToken = jwt.sign(
{
exp: expires,
data: jwtTokenPayload
},
runtimeConfig.jwtSignSecret
)
setCookie(event, 'access_token', jwtToken, {
httpOnly: true,
maxAge,
expires: new Date(expires * 1000),
secure: process.env.NODE_ENV === 'production',
path: '/'
})
return {
id: userRecord.id,
provider: {
name: userRecord.providerName,
userId: userRecord.providerUserId
},
nickname: userRecord.nickname,
avatar: userRecord.avatar,
email: userRecord.email
}
})
程式碼內容稍微有一點多,但講解一下流整與概念:
db.user.getUserByEmail
這個是我封裝的方法,裡面對應著 Prisma 的 ORM 操作,如果你想也可以在這邊替換你的資料庫操作邏輯,主要這個方法,就是依照使用者的 Email 回傳資料庫內是否存在一筆符合的使用者記錄
。provider
是否符合 Google 的使用者資訊,否則判斷為應該是用電子信箱註冊的使用者。另外,我也實作了使用電子信箱直接註冊的方式,./server/api/auth/register.post.js
程式碼如下:
import bcrypt from 'bcrypt'
import db from '@/server/db'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
let userRecord = await db.user.getUserByEmail({
email: body.email
})
if (userRecord) {
throw createError({
statusCode: 400,
statusMessage: 'A user with that email address already exists'
})
}
userRecord = await db.user.createUser({
providerName: null,
providerUserId: null,
nickname: body.nickname,
email: body.email,
password: bcrypt.hashSync(body.password, 10),
avatar: null,
emailVerified: false
})
return {
id: userRecord.id,
nickname: userRecord.nickname,
email: userRecord.email
}
})
使用電子信箱與密碼註冊的流程很簡單,就是判斷是否存在相同信箱的使用者,不存在的話就為它建立一筆紀錄。
這邊要注意的是,會員系統或牽扯到帳號密碼相關的,請一律使用雜湊演算法,例如 BCrypt 或 Argon2,為使用者的密碼做 Hash,不要再存明碼在資料庫之中囉,以免發生資安事件時,造成不可挽回的悲劇。
順便也實作一下使用電子信箱與密碼登入的 API,./server/api/auth/login.post.js
程式碼如下:
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
import db from '@/server/db'
const runtimeConfig = useRuntimeConfig()
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const userRecord = await db.user.getUserByEmail({
email: body.email
})
if (!userRecord) {
throw createError({
statusCode: 400,
statusMessage: 'Email or password is incorrect'
})
}
if ((await bcrypt.compare(body.password, userRecord.password)) !== true) {
throw createError({
statusCode: 400,
statusMessage: 'Email or password is incorrect'
})
}
const jwtTokenPayload = {
id: userRecord.id
}
const maxAge = 60 * 60 * 24 * 7
const expires = Math.floor(Date.now() / 1000) + maxAge
const jwtToken = jwt.sign(
{
exp: expires,
data: jwtTokenPayload
},
runtimeConfig.jwtSignSecret
)
setCookie(event, 'access_token', jwtToken, {
httpOnly: true,
maxAge,
expires: new Date(expires * 1000),
secure: process.env.NODE_ENV === 'production',
path: '/'
})
return {
id: userRecord.id,
provider: {
name: userRecord.providerName,
userId: userRecord.providerUserId
},
nickname: userRecord.nickname,
avatar: userRecord.avatar,
email: userRecord.email
}
})
我們可以結合 Pinia 來將使用者的資料持久話儲存在 Local Storage 之中,這樣就可以在前端儲存使用者登入的狀態,例如導覽列的頭像、信箱,就可以從 Store 中拿出來囉。
建立 ./server/profile.get.js
檔案,用來取得使用者料:
import jwt from 'jsonwebtoken'
import db from '@/server/db'
const runtimeConfig = useRuntimeConfig()
export default defineEventHandler(async (event) => {
const jwtToken = getCookie(event, 'access_token')
let userInfo = null
try {
const { data } = jwt.verify(jwtToken, runtimeConfig.jwtSignSecret)
userInfo = data
} catch (e) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized'
})
}
if (!userInfo?.id) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized'
})
}
const userRecord = await db.user.getUserById({
id: userInfo.id
})
if (!userRecord) {
throw createError({
statusCode: 400,
statusMessage: 'Could not find user.'
})
}
return {
id: userRecord.id,
provider: {
name: userRecord.providerName,
userId: userRecord.providerUserId
},
nickname: userRecord.nickname,
avatar: userRecord.avatar,
email: userRecord.email
}
})
新增一個 user 的 store,./stores/user.js
內容如下:
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
profile: {
id: null,
provider: {
name: null,
userId: null
},
nickname: null,
avatar: null,
email: null
}
}),
actions: {
async refreshUserProfile() {
const { data, error } = await useFetch('/api/user/profile', { initialCache: false })
if (data.value) {
this.profile = data.value
} else {
return error.value?.data?.message ?? '未知錯誤'
}
}
},
persist: {
enabled: true,
strategies: [
{
key: 'user',
storage: process.client ? localStorage : null
}
]
}
})
我們就可以直接使用 refreshUserProfile()
來發送請求至 /api/user/profile
取得最新的使用者資料來更新 store。
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
userStore.refreshUserProfile()
我們的會員系統在登入後,會產生一組 JWT 放置於 cookie 之中,在後端 API 使用時都要在呼叫 getCookie()
來解析 cookie,所以我們可以將驗證 JWT 的流程,放置在伺服器中間件 (middleware) 之中,後端收到的每個請求就會經過這個中間件,只要有夾帶 access_token
的 cookie 就會進行驗證解析出 JWT 所含的 payload
id,即為使用者的 ID。
建立 ./server/middleware/auth.js
檔案,內容如下:
import jwt from 'jsonwebtoken'
const runtimeConfig = useRuntimeConfig()
export default defineEventHandler((event) => {
const jwtToken = getCookie(event, 'access_token')
if (!jwtToken) {
return
}
let userInfo = null
try {
const { data } = jwt.verify(jwtToken, runtimeConfig.jwtSignSecret)
userInfo = data
if (userInfo?.id) {
event.context.auth = {
user: {
id: userInfo.id
}
}
}
} catch (e) {
console.error('Invalid token')
}
})
伺服器的中間件只要定義在 ./server/middleware
目錄下就會自動被載入,之後在每個 Server API 收到請求,中間件只要有成功驗證並解析 JWT,就會在 event.context.auth
添加使用者資訊,之後在 Server API 的處理函數中,就可以以下列程式碼進行使用。
export default defineEventHandler(async (event) => {
const user = event.context?.auth?.user
})
調整後的 ./server/profile.get.js
檔案,就會乾淨許多囉!
import db from '@/server/db'
export default defineEventHandler(async (event) => {
const user = event.context?.auth?.user
if (!user?.id) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized'
})
}
const userRecord = await db.user.getUserById({
id: user.id
})
if (!userRecord) {
throw createError({
statusCode: 400,
statusMessage: 'Could not find user.'
})
}
return {
id: userRecord.id,
provider: {
name: userRecord.providerName,
userId: userRecord.providerUserId
},
nickname: userRecord.nickname,
avatar: userRecord.avatar,
email: userRecord.email
}
})
這篇文章主要為了實作部落格,我們使用 Prisma 快速的建立資料庫環境,也方便大家可以下載範例程式碼,就可以在自己的電腦上運作 SQLite。也結合 Pinia 來將使用者的資料進行持久化的儲存,這樣我們就可以實作出如判斷使用者是否登入或是建立導覽列上的登入狀態。
感謝大家的閱讀,這是我第一次參加 iThome 鐵人賽,請鞭小力一些,也歡迎大家給予建議 :)
如果對這個 Nuxt 3 系列感興趣,可以訂閱接收通知,也歡迎分享給喜歡或正在學習 Nuxt 3 的夥伴。
初始化一個 Prisma 的 Schema 程式碼區塊npx prisma ini
這邊少了一個t喔,但圖片是對的。
已修正,感謝您的提醒:)